4.6 错误处理

返古的错误处理方式,是Go被谈及最多的内容之一。有人戏称做“Stuck in 70’s”,可见它与流行趋势背道而驰。

error

官方推荐的标准做法是返回error状态。

func Scanln(a...interface{}) (n int,err error)

标准库将error定义为接口类型,以便实现自定义错误类型。

type error interface{ 
   Error()string
}

按惯例,error总是最后一个返回参数。标准库提供了相关创建函数,可方便地创建包含简单错误文本的error对象。

var errDivByZero=errors.New("division by zero") 
  
func div(x,y int) (int,error) { 
   if y==0{ 
       return 0,errDivByZero
    } 
  
   return x/y,nil
} 
  
func main() { 
   z,err:=div(5,0) 
   if err==errDivByZero{ 
       log.Fatalln(err) 
    } 
  
   println(z) 
}

应通过错误变量,而非文本内容来判定错误类别

错误变量通常以err作为前缀,且字符串内容全部小写,没有结束标点,以便于嵌入到其他格式化字符串中输出。

全局错误变量并非没有问题,因为它们可被用户重新赋值,这就可能导致结果不匹配。不知道以后是否会出现只读变量功能,否则就只能依靠自觉了。

与errors.New类似的还有fmt.Errorf,它返回一个格式化内容的错误对象。

某些时候,我们需要自定义错误类型,以容纳更多上下文状态信息。这样的话,还可基于类型做出判断。

type DivError struct{              // 自定义错误类型 
   x,y int
} 
  
func(DivError) Error() string{          // 实现error接口方法 
   return "division by zero" 
} 
  
func div(x,y int) (int,error) { 
   if y==0{ 
       return 0,DivError{x,y}            // 返回自定义错误类型 
    } 
  
   return x/y,nil
} 
  
func main() { 
   z,err:=div(5,0) 
  
   if err!=nil{ 
       switch e:=err.(type) {            // 根据类型匹配 
       case DivError: 
           fmt.Println(e,e.x,e.y) 
       default: 
           fmt.Println(e) 
        } 
  
       log.Fatalln(err) 
    } 
  
   println(z) 
}

自定义错误类型通常以Error为名称后缀。在用switch按类型匹配时,注意case顺序。应将自定义类型放在前面,优先匹配更具体的错误类型

在正式代码中,我们不能忽略error返回值,应严格检查,否则可能会导致错误的逻辑状态。调用多返回值函数时,除error外,其他返回值同样需要关注。

以os.File.Read方法为例,它会同时返回剩余内容和EOF。

大量函数和方法返回error,使得调用代码变得很难看,一堆堆的检查语句充斥在代码行间。解决思路有:

  • 使用专门的检查函数处理错误逻辑(比如记录日志),简化检查代码。
  • 在不影响逻辑的情况下,使用defer延后处理错误状态(err退化赋值)。
  • 在不中断逻辑的情况下,将错误作为内部状态保存,等最终“提交”时再处理。

panic,recover

与error相比,panic/recover在使用方法上更接近try/catch结构化异常。

func panic(v interface{}) 
func recover()interface{}

比较有趣的是,它们是内置函数而非语句。panic会立即中断当前函数流程,执行延迟调用。而在延迟调用函数中,recover可捕获并返回panic提交的错误对象。

func main() { 
   defer func() { 
       if err:=recover();err!=nil{    // 捕获错误 
           log.Fatalln(err) 
        } 
    }() 
  
   panic("i am dead")                // 引发错误 
   println("exit.")             // 永不会执行 
}

因为panic参数是空接口类型,因此可使用任何对象作为错误状态。而recover返回结果同样要做转型才能获得具体信息。

无论是否执行recover,所有延迟调用都会被执行。但中断性错误会沿调用堆栈向外传递,要么被外层捕获,要么导致进程崩溃。

func test() { 
   defer println("test.1") 
   defer println("test.2") 
  
   panic("i am dead") 
} 
  
func main() { 
   defer func() { 
       log.Println(recover()) 
    }() 
  
   test() 
}
 

输出:

test.2
test.1
i am dead

连续调用panic,仅最后一个会被recover捕获。

func main() { 
   defer func() { 
       for{ 
           if err:=recover();err!=nil{ 
               log.Println(err) 
            }else{ 
               log.Fatalln("fatal") 
            } 
        } 
    }() 
  
   defer func() { 
       panic("you are dead")       // 类似重新抛出异常(rethrow) 
    }()            // 可先recover捕获,包装后重新抛出 
  
   panic("i am dead") 
}
 

输出:

you are dead
fatal

在延迟函数中panic,不会影响后续延迟调用执行。而recover之后panic,可被再次捕获。另外,recover必须在延迟调用函数中执行才能正常工作。

func catch() { 
   log.Println("catch:",recover()) 
} 
  
func main() { 
   defer catch()                // 捕获 
   defer log.Println(recover())           // 失败! 
   defer recover()              // 失败! 
  
   panic("i am dead") 
}

输出:

<nil> 
catch:i am dead

考虑到recover特性,如果要保护代码片段,那么只能将其重构为函数调用。

func test(x,y int) { 
   z:=0
  
   func() {                    // 利用匿名函数保护 “z=x/y” 
       defer func() { 
           if recover() !=nil{ 
               z=0
            } 
        }() 
  
       z=x/y
    }() 
  
   println("x/y=",z) 
} 
  
func main() { 
   test(5,0) 
}

调试阶段,可使用runtime/debug.PrintStack函数输出完整调用堆栈信息。

import( 
    "runtime/debug" 
) 
  
func test() { 
   panic("i am dead") 
} 
  
func main() { 
   defer func() { 
       if err:=recover();err!=nil{ 
           debug.PrintStack() 
        } 
    }() 
  
   test() 
}

输出:

goroutine 1[running]: 
main.main.func1() 
   test.go:15+0x6c
panic(0x7e3a0,0xc82000a260) 
   runtime/panic.go:426+0x4e9
main.test() 
   test.go:8+0x65
main.main() 
   test.go:20+0x35

建议:除非是不可恢复性、导致系统无法正常工作的错误,否则不建议使用panic。

例如:文件系统没有操作权限,服务端口被占用,数据库未启动等情况。